查看原文
其他

离线预渲染OPR:0成本接入 媲美SSR效果

大转转FE 大转转FE 2023-11-06


关注我们 文末有福利


作者简介


张所勇

转转平台运营中心前端负责人,在前端领域有深入研究,包括:sketch一键切图、前端数据模型化,小程序基础能力建设等多个方面,10年工作经验中,做了2年工程师,5年CEO,3年技术管理,能写点文章,也是2018年度掘金优秀作者。



细数现阶段业内首屏优化方案,主要有:SSR、Prerender、CSR等方案,这些方案的思路几乎都在于将渲染过程放到传统SPA用户端渲染之前,而传统性能优化手段在SPA项目上面收获甚微,原因在于SPA本身的致命缺陷:


SPA方案


在SPA项目的首屏性能上,我们在长期关注和不断探索,期间我们尝试过很多方案,包括:


  • 从减少代码体积角度的:webpack优化、打包优化、tree-shaking等

  • 从减少HTTP请求角度的:接口合并、按需加载、延时加载等各种方法减少请求

  • 从缓存角度出发:离线包、http&浏览器各种缓存使用、dns预解析、dll方案、接口缓存方案等

  • 从数据获取时机角度出发:webWorker预取数据、路由进入过程读取数据等

  • 从减少图片体积和数量出发:使用webp图片、请求域名并行优化、CSS Sprite等

这些方案都能一定程度上降低白屏时间和首屏时间,但收效有限,很难像SSR方案一样大幅降低数据,究其原因,SPA页面渲染过程如下:


滑动查看图片


从上图可以看到,白屏过程几乎是不可避免的,因为无论如何你去优化代码体积,Vue系列类库和你需要的其他核心类库文件加起来至少有几百K,在加上这些文件执行的时间(实测至少500ms),可能大多数情况,我们白屏时间至少1200ms-1500ms了。


当然,我们可以把骨架屏所需的css放到HTML里面,能尽早的显示出骨架屏(但很多低版本内核需下载&执行完全部script后才会渲染页面),但这并非真正的首屏,即使在性能统计上,也无法直观反馈出首屏的提升。


于是SSR方案成为我们的救命稻草:


SSR方案


我们再看下SSR如何解决这个问题:

滑动查看图片


SSR方案的优势在于,浏览器下载的HTML当中已经具备了首屏渲染所需的DOM结构和样式,白屏时间几乎等于HTML文件下载时间,而这个时间相比SPA已经很少了,性能数据有显著提升。


那为什么我们不直接用SSR方案呢?

主要原因有四点:


1、SSR项目改造成本高

Vue技术栈的SSR方案主流有两种:官方方案和Nuxt.js,这两种方案相同点都是:

  • 必须把现有webpack各项配置替换成上述两种方案工程

  • 工程所有页面都必须SSR方式的要求实现

  • 必须在自定义的asyncData/preFetch生命周期内获取数据

  • 必须将接口数据使用Vuex管理

或许你认为这个也不难啊,对于一个新项目,确实不难,但对一个老项目来讲,上述的改造成本和测试成本就无比高了,这也是少有老项目改造SSR的原因。


2、SSR性能依赖接口性能

从SSR原理上你可以知道,SSR服务端渲染过程依赖于获取到全部数据才能开始渲染,一旦接口出现延时或超时,那首屏性能也会受到影响。


3、SSR负载能力和扩容能力可能成为瓶颈

几乎是业界公认,node的负载能力相比java等要差一些,相比nginx静态资源服务更差,并且很多公司在node服务器快速扩容上面,目前还没有太多实践和机制保障,虽然可以通过备足服务器来抵抗流量高峰,但毕竟这对应的是成本。


4、SSR无降级方案

一旦node服务故障,页面可能直接就会白屏,很多时候不是重启服务能够解决的,毕竟SSR不是像SPA一样在浏览器看见什么错误去解决或者回滚就可以的,你必须真正解决了故障才能恢复服务,这期间不能很容易的降级为SPA方案。


上述原因当中,最主要阻碍我们用SSR的原因是改造成本。


Prerender方案



Prerender是基于prerender-spa-plugin这个webpack插件实现的,原理如下:

滑动查看图片


核心原理就是在webpack打包过程中,通过Puppeteer访问对应路由,抓取html并静态化,再部署cdn。


但业内这种方案使用的比较少,主要原因有:

  • 静态化过程发生在构建环节,用户访问时看到的数据注定是过时的。

  • 这种方案依赖于使用history方式的路由,这对老项目的改造测试成本也不低。

  • 编译时间大幅增加,想想就知道啦。

通过上述分析,我们能看出“最优方案”应该是SSR,不考虑负载能力的话,阻碍我们的只有改造成本了,能否用较低的成本实现跟SSR一样的效果呢?


离线预渲染OPR



晴空一声惊雷,OPR产生了,我们把他命名为离线预渲染OPR(Offline Prerender)。


OPR的渲染过程:

滑动查看图片


不同于SSR在用户访问阶段的渲染,OPR是一个独立于用户访问流程的渲染服务,它通过Puppeteer定期渲染页面并上传cdn,用户访问到的页面将会是纯静态页面,可以说是结合了SSR和Prerender两种方案。


与SSR方案的区别:

  1. 渲染过程独立于用户访问,没有服务器压力,占用资源极小,一台服务器即可完成

  2. 页面几乎不需要任何改动

  3. 渲染出来的页面效果几乎和SSR一致

  4. 可降级为SPA方案


与Prerender方案的区别:

  1. 通过定时渲染,解决Prerender方案数据无法及时更新的问题

  2. 页面几乎不需要任何改动

  3. 对原本项目构架过程无任何影响


OPR方案实现过程



我们简单拆解来看:


01

定时访问页面

我们首先搭建一个node服务,通过schedule机制定期通过Puppeteer访问需要渲染的页面。


02

等待页面渲染

页面渲染是一个动态的过程,我们如何知道页面已经渲染完了呢,Puppeteer其实提供多种方案,但我们最终选用的方案是通过监听公司性能统计埋点发出时机,通过Puppeteer的page.waitForRequest方法可以很容易实现。


03

抓取HTML

你必须清楚一点:我们抓取的是浏览器渲染的HTML,并非你请求到index.html文件内容。


前者你可以理解为,通过浏览器开发者工具,选中html标签,右键拷贝outerHTML。


后者你可以通过浏览器查看下html源码,里面应该只有空白的dom和一些<script>标签。


前者内容可能是这样的:



后者内容是这样的



其实抓取HTML这个动作通过Puppeteer一句代码就可以实现:page.$eval('html', e => e.outerHTML),但抓取到的HTML我们做了很多处理:


OPR渲染标识

为了让页面知道是被OPR渲染出来的,我们将会在HTML里面注入一个变量:__offline_prerender_data__,这个变量既起到标识作用,也可以用来存放一些特殊数据


抓取接口数据

熟悉SSR过程的同学可能知道,SSR会把服务端渲染阶段所需的数据写入到HTML中,用户端渲染时会进行一次数据校验,有了这些数据,用户端也可以尽快的完成二次渲染(下文会讲到)。


在OPR当中也可以提供这样的能力:

如果开发者在配置文件当中设置了useDataCache : true,则我们会监听页面渲染完成之前所有的接口请求,并将数据打入到HTML当中,同时帮你注入一段代码,能让HTML执行时把数据存入localStorage中,供一些接口缓存库来使用


大体代码如下:


04

解决适配问题


我们在服务端puppeteer里面模拟的设备环境跟用户实际设备肯定不一样,那我们就要解决样式适配问题:


a、在head里面注入rem刷新代码

b、将页面当中的px转换为rem


方案比较简单,这里就不贴代码了。


05

去除无用内容

我们将HTML当中无用的内容去掉了,包括:



06

对比

我们期望降低一些更新cdn的频率,因此我们把渲染好的HTML会和上次渲染的HTML进行对比,如果内容一致就不会重复渲染,其中主要有两种情况导致渲染不一致:


a:过程中页面有新上线

这时两次的HTML里<script>地址不一样了,必须会再次上线


b:页面当中数据发生了变化

  • 需要上线情况:接口数据变化了

  • 不需要上线情况:页面当中重要程度不高的数据变化,比如倒计时、购买人数等

为了减少上述不需要上线情况导致的上线,开发者可以在对应DOM上加上offline-prerender-tag-nodiff样式名,OPR在diff的过程就不会比对这块的DOM。


07

上传cdn

OPR会把渲染好的页面根据url地址上传到cdn,例如:

转转图书首页:https://m.zhuanzhuan.com/open/ZZBook/index.html#/Book/Home

会被上传到cdn:https://m.zhuanzhuan.com/open/ZZBook/index-Book-Home.html#/Book/Home


为什么不是index.html,这样有几个好处:


a:将OPR地址和SPA地址区分开

我们只需要将入口地址替换为

https://m.zhuanzhuan.com/open/ZZBook/index-Book-Home.html#/Book/Home


用户就可以访问到OPR渲染的页面了,如果用户访问到原本的index.html,那只是访问到原本的SPA页面,只不过渲染速度会慢一点而已。


b:降低风险

试想下,如果我们把文件写入成index.html,那如果用户首次访问的是/Book/Mine的路由,用户会很奇怪,我访问的是个人中心页面,为什么你要先给我展示首页呢。

同时,也防止了OPR服务发生了意外而导致所有页面都无法访问的风险。


二次渲染



上文讲过,用户访问SSR渲染的页面和OPR渲染的页面,都会发生两次渲染:


  1. 浏览器渲染HTML当中的DOM(我们叫首次渲染,注意此时页面是静态的,无法交互)

  2. Vue还会重新渲染并维护一份虚拟DOM,并把虚拟DOM和HTML当中的DOM进行一次混合(因为两个DOM完全一致,你完全看不到发生变化,但是可以进行交互了,我们简称二次渲染)


必须要有二次渲染,为什么?

因为首次渲染仅仅是DOM层面的展示,我们必须把Vue整体逻辑赋予DOM里,否则就没有各种事件,用户将无法交互。


SSR和OPR两种方式首次渲染过程都是一样的,但二次渲染有很大的区别:


SSR:

会先从html当中拿到数据,然后进行$mount渲染挂载,这里面Vue代码给SSR定制了一个特殊流程:会尝试跟页面当中DOM进行混合,如果完全一致就直接复用DOM,把虚拟DOM混合到页面中:


OPR:

OPR项目的二次渲染没有想象的那么简单,他很难做到复用DOM,主要因为:


OPR页面很难做到DOM完全一致


因为SSR项目是拿到所有数据进行一次性渲染,无论是服务端生成HTML的时机还是用户端二次渲染的时机都是完全一样的,数据也是完全一样的,因此能渲染出完全一样的DOM,如果虚拟DOM和页面中DOM有一丝毫差异,Vue都会删除掉页面已有DOM再使用新的DOM。


SPA页面会有个逐渐使用和渲染的过程,例如:


在OPR或者SPA页面中渲染是多次完成的:

1、当ready=true时先完成第一次渲染,同时开始渲染goodsList组件

2、goodsList会触发第二次渲染

而第一次渲染时DOM和页面当中并不一致,那么旧的DOM就直接被抛弃掉了。


那如果上述页面我们把结构写的更简单一点可以吗,比如所有组件都平铺开,不用v-if控制,所有数据都使用缓存数据,并且同步读取数据不能包含异步流程,这样理论上能完全复用DOM,但现实中是不太可能的,业务当中逻辑远比想象的负责,我们当然也不希望业务进行大量改造,这就违背OPR的初衷了。


因此OPR方案选择的二次渲染解决方案是:延时挂载DOM


这个方案的核心在于,只要你在new Vue的过程中,不传el参数,那么Vue就会在没有挂载到页面的DOM当中完成渲染和虚拟DOM的构建工作,那我们只要在一个合适的时机把DOM挂到页面上就行了,这个合适的时机是什么呢?


页面DOM和虚拟DOM接近一致


我们先说下“挂载”是怎样的过程:简而言之就是把HTML当中DOM删掉,再把虚拟DOM的句柄window.vm.$el插入body中


大家可能会担心有性能损耗吧,实际上这个DOM删除和插入的过程非常快,对于浏览器进程资源消耗非常少。


再回到挂载时机,如果页面差异过大的时候挂载会怎么样?那用户会感到页面闪动,因为有局部样子不一致,这个应该比较好理解,所以我们要在两个DOM接近一致的时候做挂载操作


延迟挂载时机



为了能知道何时两个DOM接近一致,我们借鉴了淘宝的性能统计方案中的首屏结束时机的计算方案:


我们通过MutationObserver来监听虚拟DOM的变化,每次变化时计算一次DOM分数,当DOM分数和页面已有DOM分数接近的时候,我们去挂载DOM


具体逻辑更复杂一点:

滑动查看图片


当两个DOM几乎一致的时候,再去挂载DOM,用户就几乎感觉不到页面闪动了。


同时参考上文,如果使用了接口缓存机制,就减少DOM趋近一致的时间了。


其他优化


为了更进一步提升首屏速度,我们在OPR方案上还做了一些优化:


01

CSS tree-shaking


我们抓取的HTML里面会有很多style标签,几乎可以认为,你曾在vue里面写了多少个style标签,head里面就至少有这么多标签,例如:


这些标签里面有大量重复的样式,如果打入HTML将增大文件体积,我们对此进行了tree-shaking,可以讲原本5000行+的css代码精简到1000+,具体如何实现,我们将会在以后的文章中讲解。


02

将script改为async加载模式


在此之前,你可能需要了解下普通script、defer、async有什么区别。

简而言之,

  • 普通script:  按书写顺序下载、执行,阻碍DOM渲染,阻碍DOMContentLoaded时间

  • defer script:并行下载,按照书写顺序执行,不阻碍DOM渲染,阻碍DOMContentLoaded时间

  • async script:并行下载,谁先下载完谁先执行,不阻碍DOM渲染,不阻碍DOMContentLoaded时间


为什么我们要用async,在某项低版本iOS内核中,会等待页面DOMContentLoaded才开始渲染页面,如果用普通script或者defer模式,会延迟页面渲染时间。


03

降级策略


如果出现任何问题,为了保证用户可访问,我们会做出降级方案,也很简单:

抓取HTML本身的文本内容,同步到cdn,相当于把OPR模式退回到SPA了,这个策略在OPR方案中会有相应的检测方式和自动切换机制。


效果



OPR方案已经在我司多个业务接入,可以把原本首屏在1500ms-4000ms的SPA页面下降到300ms-800ms,可以说是效果显著了~


最后说明



OPR方案目前应用于我司各业务无需登录态的首页及重要列表渲染,下一阶段我们将解决对登录态有依赖的页面渲染,欢迎读者与我司交流探讨。


文末福利



转发本文并留下评论,我们将抽取第10名留言者(依据公众号后台顺序)送出转转纪念T恤一件:




扫描二维码

关注我们

一个有意思的前端团队


继续滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存